package org.eclipse.che.core.internal.preferences;

import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.internal.preferences.Base64;
import org.eclipse.core.internal.preferences.ImmutableMap;
import org.eclipse.core.internal.preferences.PrefsMessages;
import org.eclipse.core.internal.preferences.SafeFileInputStream;
import org.eclipse.core.internal.preferences.SafeFileOutputStream;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.preferences.IEclipsePreferences;
import org.eclipse.core.runtime.preferences.IPreferenceNodeVisitor;
import org.osgi.service.prefs.BackingStoreException;
import org.osgi.service.prefs.Preferences;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Collections;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;

/**
 * @author Evgen Vidolob
 */
public class ChePreferences implements IEclipsePreferences {

    protected static final String VERSION_KEY = "eclipse.preferences.version"; //$NON-NLS-1$
    protected static final String VERSION_VALUE = "1"; //$NON-NLS-1$
    protected static final String DOUBLE_SLASH = "//"; //$NON-NLS-1$
    protected static final String EMPTY_STRING = ""; //$NON-NLS-1$
    private static final String FALSE = "false"; //$NON-NLS-1$
    private static final String TRUE = "true"; //$NON-NLS-1$
    /**
     * Protects write access to properties and children.
     */
    protected ImmutableMap properties = ImmutableMap.EMPTY;
    private final Object childAndPropertyLock = new Object();
    protected boolean dirty = false;
    private final String filePath;

    public ChePreferences(String filePath) {
        this.filePath = filePath;
    }

    /**
     * Loads the preference node. This method returns silently if the node does not exist
     * in the backing store (for example non-existent project).
     *
     * @throws BackingStoreException if the node exists in the backing store but it
     * could not be loaded
     */
    protected void load() throws BackingStoreException {
            load(filePath);
    }

    protected void load(String location) throws BackingStoreException {
        if (location == null) {
            return;
        }
        Properties fromDisk = loadProperties(location);
        convertFromProperties(this, fromDisk, false);
    }

    protected static Properties loadProperties(String location) throws BackingStoreException {
//        if (DEBUG_PREFERENCE_GENERAL)
//            PrefsMessages.message("Loading preferences from file: " + location); //$NON-NLS-1$
        InputStream input = null;
        Properties result = new Properties();
        try {
            input = new SafeFileInputStream(new File(location));
            result.load(input);
        } catch (FileNotFoundException e) {
            // file doesn't exist but that's ok.
//            if (DEBUG_PREFERENCE_GENERAL)
//                PrefsMessages.message("Preference file does not exist: " + location); //$NON-NLS-1$
            return result;
        } catch (IOException e) {
//            String message = NLS.bind(PrefsMessages.preferences_loadException, location);
//            log(new Status(IStatus.INFO, PrefsMessages.OWNER_NAME, IStatus.INFO, message, e));
            throw new BackingStoreException(e.getMessage(), e);
        } finally {
            if (input != null)
                try {
                    input.close();
                } catch (IOException e) {
                    // ignore
                }
        }
        return result;
    }

    /*
 * Helper method to convert this node to a Properties file suitable
 * for persistence.
 */
    protected Properties convertToProperties(Properties result, String prefix) throws BackingStoreException {
        // add the key/value pairs from this node
        boolean addSeparator = prefix.length() != 0;
        //thread safety: copy reference in case of concurrent change
        ImmutableMap temp;
        synchronized (childAndPropertyLock) {
            temp = properties;
        }
        String[] keys = temp.keys();
        for (int i = 0, imax = keys.length; i < imax; i++) {
            String value = temp.get(keys[i]);
            if (value != null)
                result.put(encodePath(prefix, keys[i]), value);
        }
//        // recursively add the child information
//        IEclipsePreferences[] childNodes = getChildren(true);
//        for (int i = 0; i < childNodes.length; i++) {
//            ChePreferences child = (ChePreferences) childNodes[i];
//            String fullPath = addSeparator ? prefix + PATH_SEPARATOR + child.name() : child.name();
//            child.convertToProperties(result, fullPath);
//        }
        return result;
    }

    /*
 * Encode the given path and key combo to a form which is suitable for
 * persisting or using when searching. If the key contains a slash character
 * then we must use a double-slash to indicate the end of the
 * path/the beginning of the key.
 */
    public static String encodePath(String path, String key) {
        String result;
        int pathLength = path == null ? 0 : path.length();
        if (key.indexOf(IPath.SEPARATOR) == -1) {
            if (pathLength == 0)
                result = key;
            else
                result = path + IPath.SEPARATOR + key;
        } else {
            if (pathLength == 0)
                result = DOUBLE_SLASH + key;
            else
                result = path + DOUBLE_SLASH + key;
        }
        return result;
    }

    /*
	 * Version 1 (current version)
	 * path/key=value
	 */
    protected static void convertFromProperties(ChePreferences node, Properties table, boolean notify) {
        String version = table.getProperty(VERSION_KEY);
        if (version == null || !VERSION_VALUE.equals(version)) {
            // ignore for now
        }
        table.remove(VERSION_KEY);
        for (Iterator i = table.keySet().iterator(); i.hasNext();) {
            String fullKey = (String) i.next();
            String value = table.getProperty(fullKey);
            if (value != null) {
                String[] splitPath = decodePath(fullKey);
                String path = splitPath[0];
                path = makeRelative(path);
                String key = splitPath[1];
//                if (DEBUG_PREFERENCE_SET)
//                    PrefsMessages.message("Setting preference: " + path + '/' + key + '=' + value); //$NON-NLS-1$
                //use internal methods to avoid notifying listeners
                ChePreferences childNode = (ChePreferences) node.internalNode(path, false, null);
                String oldValue = childNode.internalPut(key, value);
//                // notify listeners if applicable
//                if (notify && !value.equals(oldValue))
//                    childNode.firePreferenceEvent(key, oldValue, value);
            }
        }
//        PreferencesService.getDefault().shareStrings();
    }

    /**
     * Stores the given (key,value) pair, performing lazy initialization of the
     * properties field if necessary. Returns the old value for the given key,
     * or null if no value existed.
     */
    protected String internalPut(String key, String newValue) {
        synchronized (childAndPropertyLock) {
            // illegal state if this node has been removed
//            checkRemoved();
            String oldValue = properties.get(key);
            if (oldValue != null && oldValue.equals(newValue))
                return oldValue;
//            if (DEBUG_PREFERENCE_SET)
//                PrefsMessages.message("Setting preference: " + absolutePath() + '/' + key + '=' + newValue); //$NON-NLS-1$
            properties = properties.put(key, newValue);
            return oldValue;
        }
    }

    /**
     * Implements the node(String) method, and optionally notifies listeners.
     */
    protected IEclipsePreferences internalNode(String path, boolean notify, Object context) {

//         illegal state if this node has been removed
//        checkRemoved();

        // short circuit this node
//        if (path.length() == 0)
        //TODO only
            return this;
//
//        // if we have an absolute path use the root relative to
//        // this node instead of the global root
//        // in case we have a different hierarchy. (e.g. export)
//        if (path.charAt(0) == IPath.SEPARATOR)
//            return (IEclipsePreferences) calculateRoot().node(path.substring(1));
//
//        int index = path.indexOf(IPath.SEPARATOR);
//        String key = index == -1 ? path : path.substring(0, index);
//        boolean added = false;
//        IEclipsePreferences child = getChild(key, context, true);
//        if (child == null) {
//            child = create(this, key, context);
//            added = true;
//        }
//        // notify listeners if a child was added
//        if (added && notify)
//            fireNodeEvent(new NodeChangeEvent(this, child), true);
//        return (IEclipsePreferences) child.node(index == -1 ? EMPTY_STRING : path.substring(index + 1));
    }

//    /**
//     * Thread safe way to obtain a child for a given key. Returns the child
//     * that matches the given key, or null if there is no matching child.
//     */
//    protected IEclipsePreferences getChild(String key, Object context, boolean create) {
//        synchronized (childAndPropertyLock) {
//            if (children == null)
//                return null;
//            Object value = children.get(key);
//            if (value == null)
//                return null;
//            if (value instanceof IEclipsePreferences)
//                return (IEclipsePreferences) value;
//            // if we aren't supposed to create this node, then
//            // just return null
//            if (!create)
//                return null;
//        }
//        return addChild(key, create(this, key, context));
//    }

    private IEclipsePreferences calculateRoot() {
        IEclipsePreferences result = this;
        while (result.parent() != null)
            result = (IEclipsePreferences) result.parent();
        return result;
    }
    /*
 * Return a relative path
 */
    public static String makeRelative(String path) {
        String result = path;
        if (path == null)
            return EMPTY_STRING;
        if (path.length() > 0 && path.charAt(0) == IPath.SEPARATOR)
            result = path.length() == 0 ? EMPTY_STRING : path.substring(1);
        return result;
    }

    /*
 * Return a 2 element String array.
 * 	element 0 - the path
 * 	element 1 - the key
 * The path may be null.
 * The key is never null.
 */
    public static String[] decodePath(String fullPath) {
        String key = null;
        String path = null;

        // check to see if we have an indicator which tells us where the path ends
        int index = fullPath.indexOf(DOUBLE_SLASH);
        if (index == -1) {
            // we don't have a double-slash telling us where the path ends
            // so the path is up to the last slash character
            int lastIndex = fullPath.lastIndexOf(IPath.SEPARATOR);
            if (lastIndex == -1) {
                key = fullPath;
            } else {
                path = fullPath.substring(0, lastIndex);
                key = fullPath.substring(lastIndex + 1);
            }
        } else {
            // the child path is up to the double-slash and the key
            // is the string after it
            path = fullPath.substring(0, index);
            key = fullPath.substring(index + 2);
        }

        // adjust if we have an absolute path
        if (path != null)
            if (path.length() == 0)
                path = null;
            else if (path.charAt(0) == IPath.SEPARATOR)
                path = path.substring(1);

        return new String[] {path, key};
    }

    @Override
    public void addNodeChangeListener(INodeChangeListener listener) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void removeNodeChangeListener(INodeChangeListener listener) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void addPreferenceChangeListener(IPreferenceChangeListener listener) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void removePreferenceChangeListener(IPreferenceChangeListener listener) {
        throw new UnsupportedOperationException();
    }

    @Override
    public void removeNode() throws BackingStoreException {
        throw new UnsupportedOperationException();
    }

    @Override
    public String name() {
        throw new UnsupportedOperationException();
    }

    @Override
    public String absolutePath() {
        throw new UnsupportedOperationException();
    }

    @Override
    public void flush() throws BackingStoreException {
        IEclipsePreferences toFlush = null;
        synchronized (childAndPropertyLock) {
            toFlush = internalFlush();
        }
        //if we aren't at the right level, then flush the appropriate node
        if (toFlush != null)
            toFlush.flush();
    }

    /*
 * Do the real flushing in a non-synchronized internal method so sub-classes
 * (mainly ProjectPreferences and ProfilePreferences) don't cause deadlocks.
 *
 * If this node is not responsible for persistence (a load level), then this method
 * returns the node that should be flushed. Returns null if this method performed
 * the flush.
 */
    protected IEclipsePreferences internalFlush() throws BackingStoreException {
//         illegal state if this node has been removed
//        checkRemoved();

//        IEclipsePreferences loadLevel = null;//getLoadLevel();

//        // if this node or a parent is not the load level, then flush the children
//        if (loadLevel == null) {
//            String[] childrenNames = childrenNames();
//            for (int i = 0; i < childrenNames.length; i++)
//                node(childrenNames[i]).flush();
//            return null;
//        }
//
//        // a parent is the load level for this node
//        if (this != loadLevel)
//            return loadLevel;

        // this node is a load level
        // any work to do?
        if (!dirty)
            return null;
        //remove dirty bit before saving, to ensure that concurrent
        //changes during save mark the store as dirty
        dirty = false;
        try {
            save();
        } catch (BackingStoreException e) {
            //mark it dirty again because the save failed
            dirty = true;
            throw e;
        }
        return null;
    }

    /**
     * Saves the preference node. This method returns silently if the node does not exist
     * in the backing store (for example non-existent project)
     *
     * @throws BackingStoreException if the node exists in the backing store but it
     * could not be saved
     */
    protected void save() throws BackingStoreException {
//        if (descriptor == null) {
            save(filePath);
//        } else {
//            descriptor.save(absolutePath(), convertToProperties(new Properties(), "")); //$NON-NLS-1$
//        }
    }

    protected void save(String location) throws BackingStoreException {
        if (location == null) {
//            if (DEBUG_PREFERENCE_GENERAL)
//                PrefsMessages.message("Unable to determine location of preference file for node: " + absolutePath()); //$NON-NLS-1$
            return;
        }
//        if (DEBUG_PREFERENCE_GENERAL)
//            PrefsMessages.message("Saving preferences to file: " + location); //$NON-NLS-1$
        Properties table = convertToProperties(new SortedProperties(), EMPTY_STRING);
        if (table.isEmpty()) {
            File file = new File(location);
            // nothing to save. delete existing file if one exists.
            if (file.exists() && !file.delete()) {
//                String message = NLS.bind(PrefsMessages.preferences_failedDelete, location);
                ResourcesPlugin.log(new Status(IStatus.WARNING, PrefsMessages.OWNER_NAME, IStatus.WARNING,
                                               "preferences save failed, file was delete", null));
            }
            return;
        }
        table.put(VERSION_KEY, VERSION_VALUE);
        write(table, location);
    }

    /*
 * Helper method to persist a Properties object to the filesystem. We use this
 * helper so we can remove the date/timestamp that Properties#store always
 * puts in the file.
 */
    protected static void write(Properties properties, String location) throws BackingStoreException {
        // create the parent directories if they don't exist
//        File parentFile = new File(location);
//        if (parentFile == null)
//            return;
//        parentFile.mkdirs();

        OutputStream output = null;
        try {
            File file = new File(location);
            if(!file.exists()){
                File parentFile = file.getParentFile();
                if(!parentFile.exists()){
                    parentFile.mkdirs();
                }
                file.createNewFile();
            }
            output = new SafeFileOutputStream(file);
            output.write(removeTimestampFromTable(properties).getBytes("UTF-8")); //$NON-NLS-1$
            output.flush();
        } catch (IOException e) {
//            String message = NLS.bind(PrefsMessages.preferences_saveException, location);
            ResourcesPlugin.log(new Status(IStatus.ERROR, PrefsMessages.OWNER_NAME, IStatus.ERROR, "preferences_saveException", e));
            throw new BackingStoreException("preferences_saveException");
        } finally {
            if (output != null)
                try {
                    output.close();
                } catch (IOException e) {
                    // ignore
                }
        }
    }

    protected static String removeTimestampFromTable(Properties properties) throws IOException {
        // store the properties in a string and then skip the first line (date/timestamp)
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        try {
            properties.store(output, null);
        } finally {
            output.close();
        }
        String string = output.toString("UTF-8"); //$NON-NLS-1$
        String separator = System.getProperty("line.separator"); //$NON-NLS-1$
        return string.substring(string.indexOf(separator) + separator.length());
    }

    @Override
    public void sync() throws BackingStoreException {
        load();
        flush();
    }

    @Override
    public void put(String key, String newValue) {
        if (key == null || newValue == null)
            throw new NullPointerException();
        String oldValue = internalPut(key, newValue);
        if (!newValue.equals(oldValue)) {
            makeDirty();
//            firePreferenceEvent(key, oldValue, newValue);
        }
    }

    protected void makeDirty() {
//        EclipsePreferences node = this;
//        while (node != null && !node.removed) {
          dirty = true;
//            node = (EclipsePreferences) node.parent();
//        }
    }

    @Override
    public String get(String key, String defaultValue) {
        String value = internalGet(key);
        return value == null ? defaultValue : value;
    }


    /**
     * Returns the existing value at the given key, or null if
     * no such value exists.
     */
    protected String internalGet(String key) {
        // throw NPE if key is null
        if (key == null)
            throw new NullPointerException();
        // illegal state if this node has been removed
//        checkRemoved();
        String result;
        synchronized (childAndPropertyLock) {
            result = properties.get(key);
        }
//        if (DEBUG_PREFERENCE_GET)
//            PrefsMessages.message("Getting preference value: " + absolutePath() + '/' + key + "->" + result); //$NON-NLS-1$ //$NON-NLS-2$
        return result;
    }

    @Override
    public void remove(String key) {
        String oldValue;
        synchronized (childAndPropertyLock) {
            // illegal state if this node has been removed
//            checkRemoved();
            oldValue = properties.get(key);
            if (oldValue == null)
                return;
            properties = properties.removeKey(key);
        }
        makeDirty();
    }

    @Override
    public void clear() throws BackingStoreException {
// illegal state if this node has been removed
//        checkRemoved();
        // call each one separately (instead of Properties.clear) so
        // clients get change notification
        String[] keys;
        synchronized (childAndPropertyLock) {
            keys = properties.keys();
        }
        //don't synchronize remove call because it calls listeners
        for (int i = 0; i < keys.length; i++)
            remove(keys[i]);
        makeDirty();
    }

    @Override
    public void putInt(String key, int value) {
        if (key == null)
            throw new NullPointerException();
        String newValue = Integer.toString(value);
        String oldValue = internalPut(key, newValue);
        if (!newValue.equals(oldValue)) {
            makeDirty();
//            firePreferenceEvent(key, oldValue, newValue);
        }
    }

    @Override
    public int getInt(String key, int defaultValue) {
        String value = internalGet(key);
        int result = defaultValue;
        if (value != null)
            try {
                result = Integer.parseInt(value);
            } catch (NumberFormatException e) {
                // use default
            }
        return result;
    }

    @Override
    public void putLong(String key, long value) {
        if (key == null)
            throw new NullPointerException();
        String newValue = Long.toString(value);
        String oldValue = internalPut(key, newValue);
        if (!newValue.equals(oldValue)) {
            makeDirty();
//            firePreferenceEvent(key, oldValue, newValue);
        }
    }

    @Override
    public long getLong(String key, long defaultValue) {
        String value = internalGet(key);
        long result = defaultValue;
        if (value != null)
            try {
                result = Long.parseLong(value);
            } catch (NumberFormatException e) {
                // use default
            }
        return result;
    }

    @Override
    public void putBoolean(String key, boolean value) {
        if (key == null)
            throw new NullPointerException();
        String newValue = value ? TRUE : FALSE;
        String oldValue = internalPut(key, newValue);
        if (!newValue.equals(oldValue)) {
            makeDirty();
//            firePreferenceEvent(key, oldValue, newValue);
        }
    }

    @Override
    public boolean getBoolean(String key, boolean defaultValue) {
        String value = internalGet(key);
        return value == null ? defaultValue : TRUE.equalsIgnoreCase(value);
    }

    @Override
    public void putFloat(String key, float value) {
        if (key == null)
            throw new NullPointerException();
        String newValue = Float.toString(value);
        String oldValue = internalPut(key, newValue);
        if (!newValue.equals(oldValue)) {
            makeDirty();
//            firePreferenceEvent(key, oldValue, newValue);
        }
    }

    @Override
    public float getFloat(String key, float defaultValue) {
        String value = internalGet(key);
        float result = defaultValue;
        if (value != null)
            try {
                result = Float.parseFloat(value);
            } catch (NumberFormatException e) {
                // use default
            }
        return result;
    }

    @Override
    public void putDouble(String key, double value) {
        if (key == null)
            throw new NullPointerException();
        String newValue = Double.toString(value);
        String oldValue = internalPut(key, newValue);
        if (!newValue.equals(oldValue)) {
            makeDirty();
//            firePreferenceEvent(key, oldValue, newValue);
        }
    }

    @Override
    public double getDouble(String key, double defaultValue) {
        String value = internalGet(key);
        double result = defaultValue;
        if (value != null)
            try {
                result = Double.parseDouble(value);
            } catch (NumberFormatException e) {
                // use default
            }
        return result;
    }

    @Override
    public void putByteArray(String key, byte[] value) {
        if (key == null || value == null)
            throw new NullPointerException();
        String newValue = new String(Base64.encode(value));
        String oldValue = internalPut(key, newValue);
        if (!newValue.equals(oldValue)) {
            makeDirty();
//            firePreferenceEvent(key, oldValue, newValue);
        }
    }

    @Override
    public byte[] getByteArray(String key, byte[] defaultValue) {
        String value = internalGet(key);
        return value == null ? defaultValue : Base64.decode(value.getBytes());
    }

    @Override
    public String[] keys() throws BackingStoreException {
        // illegal state if this node has been removed
        synchronized (childAndPropertyLock) {
//            checkRemoved();
            return properties.keys();
        }
    }

    @Override
    public String[] childrenNames() throws BackingStoreException {
        throw new UnsupportedOperationException();
    }

    @Override
    public Preferences parent() {
        throw new UnsupportedOperationException();
    }

    @Override
    public Preferences node(String path) {
        throw new UnsupportedOperationException();
    }

    @Override
    public boolean nodeExists(String pathName) throws BackingStoreException {
        return false;
    }

    @Override
    public void accept(IPreferenceNodeVisitor visitor) throws BackingStoreException {
        throw new UnsupportedOperationException();
    }
    protected class SortedProperties extends Properties {

        private static final long serialVersionUID = 1L;

        public SortedProperties() {
            super();
        }

        /* (non-Javadoc)
         * @see java.util.Hashtable#keys()
         */
        public synchronized Enumeration keys() {
            TreeSet set = new TreeSet();
            for (Enumeration e = super.keys(); e.hasMoreElements();)
                set.add(e.nextElement());
            return Collections.enumeration(set);
        }

        /* (non-Javadoc)
         * @see java.util.Hashtable#entrySet()
         */
        public Set entrySet() {
            TreeSet set = new TreeSet(new Comparator() {
                public int compare(Object e1, Object e2) {
                    String s1 = (String) ((Map.Entry) e1).getKey();
                    String s2 = (String) ((Map.Entry) e2).getKey();
                    return s1.compareTo(s2);
                }
            });
            for (Iterator i = super.entrySet().iterator(); i.hasNext();)
                set.add(i.next());
            return set;
        }
    }

}
